移除不必要的中间虚拟层只是性能优化的第一步,如果能控制操作系统层,使JVM知道该如何与其进行通信的话,就能使JVM获得更多重要信息,进而极大的扩展自适应运行时的能力。
这一节的内容更趋向于推测,其中所设计到的技术在将来可能会发生变化,但不出意外的话,它们应该就是高性能虚拟化的基础。
提高热点代码采样信息的质量可以提升系统的整体性能。在第2章中曾介绍过,采样信息越详细,代码优化的质量就越高。当在系统中运行调度器来控制所有的线程时,采样的开销会急剧增大。要想查找Java指令寄存器的内容,无需调用开销巨大的操作系统调用(改变 RING级别)来暂停所有的应用程序线程,通过JVM内部实现的绿色线程(参见第4章内容)就可以实现。启动/终止绿色线程的开销极小,跟操作系统线程相比完全不是一个数量级。JRockit VE中的线程实现与绿色线程颇有相似之处。
JRockit VE中的采样质量堪比基于硬件的采样(参见第2章),使JVM能够获得更多的运行时信息,为指定优化策略提供数据支持,提升决策的准确性。
除了采样信息之外,另一个性能提升点在于启用堆大小的自适应调整。
大多数虚拟机管理程序都支持名为 膨胀(ballooning)的技术,该技术使虚拟机管理程序和客户应用程序可以就内存的使用情况进行协商,而无需破坏各个客户应用程序之间的沙箱模型。就具体实现来说,就是实现一个膨胀驱动程序(balloon driver)的中间层,为客户应用程序提供一个伪虚拟设备,虚拟机管理程序通过该伪虚拟设备提示客户应用程序需要扩大内存。当内存不足,需要回收一些其他客户应用程序占用的内存时,客户应用程序可以通过膨胀驱动程序解析出出由虚拟机管理程序发出的有关"扩大内存"的提示。
此外,膨胀还可以实现内存的超量使用,例如使各个客户应用程序占用的总内存可以超过物理内存的总量。鉴于该技术可用于实现无换页操作,因此可说是非常强大的技术。
由于JVM中Java堆所占用的内存总量比JRockit VE内核所占用的本地内存总量大上几个数量级,因此对于虚拟机管理程序来说,回收内存最有效的方式就是对Java堆做伸缩处理。如果虚拟机管理程序通过膨胀驱动程序报告内存压力过大,则JVM应该通过外部API(所谓"外部",是指JRockit VE内核暴露给JVM的接口)来收缩Java堆,这个调用可能会触发Java堆的内存整理操作。
另一方面,如果垃圾回收的执行时间过长,则JVM应该询问JRockit VE内核是否需要通过虚拟机管理程序回收一些内存。这个询问操作只存在于JRockit和JRockit VE平台,是作为JRockit VE平台抽象层供JVM使用的。
传统的操作系统并不支持"提示进程需要释放/增加内存"的操作,对于自适应内存管理来说,这完全可以再开一章单独详述了。因此,JRockit VE可以保证JVM可以恰到好处的使用内存,及时释放内存供其它客户应用程序使用,避免因换页操作(内存不足时可能需要执行换页操作)而带来的性能开销(这开销很大)。这个特性使JRockit VE非常适合作为虚拟环境来运行Java应用程序,可以及时调整各个客户应用程序的运行配置,最大化资源利用率。
移除JVM和硬件之间的操作系统层可能会带来意想不到的大收益。
回想一下标准操作系统中进程和线程的概念,就定义来说,同一进程中的线程会共享虚拟内存,而且并没有内置的内存保护机制来保护线程对内存的并发访问,但不同进程则肯定不能访问对方的内存数据。现在假设每个线程都可以保留一部分其他线程无法访问的内存(当然,其他进程的线程就更不能了)。如果某个线程试图访问另一个线程私有内存区域,则可能会产生一个页错误(page fault)。这种机制与标准操作系统中的页保护相类似,只不过控制粒度更精细,实现这个机制并不复杂,JRockit VE就是通过改变线程的定义来实现的。
对于标准操作系统来说,要想在JVM实现这种快速、透明的线程内页保护机制是不可能的,但对于像JRockit VE内核这样的"操作系统"来说,那就小菜一碟了。Oracle已经就这种技术申请了多项专利。
13.3.3.1节和13.3.3.2节的内容阐述了这这项技术强大的威力。
正如第3章中介绍的,在Java中实现线程局部对象分配是大有裨益的,可以避免重复在Java堆中创建对象,由于在堆中创建对象需要做同步处理,因此会带来额外的性能开销。
此外,由于绝大部分Java对象的生命周期都非常短,因此将之存放在年轻代可以提升垃圾回收的吞吐量。对于很多Java应用程序来说,大部分对象的生命周都只限于线程内部,在其他线程看到该对象之前那就会回收掉。
如果能够以较低的开销将线程局部分配(thread local allocation area)扩展为更小的、自包含的线程局部堆(thread local heap),理论上是可以大幅提升系统整体性能的。就具体实现来说,只要能保证目标对象,即那些只会在当前线程内使用的对象, 对线程局部堆的垃圾回收可以以无锁(lock free)的方式完成。如果程序中所用到的对象都只存活于线程内的话,则实现无延迟、无暂停的垃圾回收就不再是梦想了。当然,这只是设想而已。由于线程局部堆之间是相互隔离的,因此对各个线程局部堆的垃圾回收可以分开执行,有助于降低垃圾回收造成的系统延迟。
除了线程局部堆之外,全局堆(一般来说,会占用系统内存的大部分空间)用于放置共享于各个线程之间对象,因而需要使用标准垃圾回收操作。全局堆中的对象可能胡引用线程局部堆中的对象,因此在对线程局部堆做垃圾回收时,垃圾回收器需要额外处理这种关联关系。
实现这种机制的主要问题在于如何识别出对象的线程可见性(是否可被其他线程看到)的改变。对线程局部堆中对象的属性做读写操作都可能会改变其线程可见性,可能需要将目标对象从线程局部堆提升到全局堆中。为简化实现,可以禁用两个两个线程局部堆中的对象互相引用。
对于标准操作系统来说,若想在标准JVM实现这种机制,需要在读写对象属性时添加读屏障和写屏障,执行开销不小。屏障会检查访问对象的线程和创建对象是否是同一个,如果不是同一个,而且该对象之前从未被其他线程看到过,则需要将该对象提升到全局堆中;如果是创建者线程(创建目标对象的线程)访问自己创建的对象,则啥也不做,让对象在线程局部堆中待着就好。
这种机制的伪代码如下所示:
//someone reads an object from "x.field"
void checkReadAccess(Object x) {
int myTid = getThreadId();
//if this object is thread local & belongs to another
//thread, evacuate it to global heap
if (!x.isOnGlobalHeap() && !x.internalTo(myTid)) {
x.evacuateToGlobalHeap();
}
}
//someone writes object "y" to "x.field"
void checkWriteAccess(Object x, Object y) {
if (x.isOnGlobalHeap() && !y.isOnGlobalHeap()) {
GC.registerGlobalToLocalReference(x, y);
}
}
在64位机器上,内存地址空间大而稳定,因此在实现时,可以使用对象地址的部分二进制位来标记对象从属于哪个线程局部堆,这样读屏障和写屏障的快速路经,即只检查线程是否还只局限在创建者线程内,就可以通过几条汇编指令来实现了。不过,就算是所有访问对象的操作都源自于创建者线程,这种线程可见性的检查还是会带来一些性能开销,浪费宝贵的寄存器资源,因为每个读屏障和写屏障都需要执行额外的本地指令,自然地,对于慢速路径(译者注,估计是做对象提升操作)来说,开销就更大了。
Österdahl等人的研究表明,屏障所产生的执行开销使其不适合在通用操作系统中实现JVM的线程局部垃圾回收,但如果在能在线程级实现页保护机制的话,则至少可以使读屏障和写屏障的实现更轻,具体实现来说,可以在非创建者线程文档位于线程局部堆中的对象时,触发一个页错误来通知系统需要改变对象的线程可见性,这样就不必显式地执行屏障代码了。
当然,即便是提升了读屏障/写屏障的执行性能,但由于对象可能会被频繁的提升到全局堆中,因此线程局部垃圾回收仍会增加系统整体的执行开销。以生产者-消费者模型为例,生产者线程创建的对象会不断地被消费者线程所获取,因而完全不应该使用线程局部垃圾回收。不过万幸的是,大部分应用程序都适用于分代式垃圾回收,而且其中大部分对象在其生命周期内斗只对创建者线程可见。
本节所介绍的技术可谓是一种"赌博式"的优化,这种"赌博式"的优化技术广泛应用于自适应运行时的各个领域领域,如果"赌输了",后果很严重。线程局部垃圾回收看起来很美好,实现起来却很复杂,目前还没法判断它是否可用于生产环境中。
线程间内存保护另一个发展方向就是如何以较少的同步操作来执行并行任务,例如垃圾回收器执行堆内存整理操作。堆内存整理的开销很大,因为对象的引用关系可能会跨越整个内存堆。多线程执行堆内存整理需要在线程之间同步对象的引用关系,降低了任务的并行性,即使将堆分化成几个分区,各个分区由不同的线程分别处理,也还是需要在各个线程之间同步对象的引用关系,判断不同线程之间的操作是否会相关影响。
如果线程间的页保护机制能完成检查线程间同步检查的工作,那实现并发堆内存整理就简单多了,还能提升执行性能。当正在执行整理操作的线程需要与其他线程进行交互时,可以通过触发页错误的形式来完成,而无须在垃圾回收器中显式处理,从而可以减少整理算法中的同步操作。